Bundler: 어떤 일을 해주나

ESM의 등장으로 브라우저도 이제 모듈 시스템을 쓸 수 있게 됐다.

하지만, 애플리케이션이 200개의 모듈로 구성되어 있다면, 브라우저는 페이지 로딩 시 다음과 같은 일을 수행한다.

1. HTML에서 `<script type="module">` 발견
2. 첫 모듈을 fetch
3. import를 발견할 때마다 또 fetch
4.import → 또 fetch

  …
  총 200개의 요청이 발생

HTTP 요청은 비싸다. 특히 모바일 환경이나 느린 네트워크에서는 더 치명적이다. 네트워크가 병목이 되어 버리면, 화면이 늦게 뜨고 인터랙션도 느려진다. 그래서 등장한 것이 Bundler 다.

Bundler의 핵심 개념

번들러의 역할은 매우 간단히 말하면 이렇다.
여러 개의 모듈을 분석해서, 브라우저가 효율적으로 받아볼 수 있는 형태로 가공하는 도구

초기 번들러의 목표는 여러 모듈을 하나의 파일로 묶는 것이었지만,
시간이 지나면서 번들러는 “빌드 도구 전체”가 되었다. 트랜스파일, 압축, 청크 분할, 캐싱 전략, 최적화까지 맡게 되었다.

Bundler는 실제로 어떻게 동작 하는가

  1. Entry(진입점) 분석

    src/index.ts 같은 엔트리 파일에서 시작하여 import를 따라가며 의존성 그래프(Dependency Graph) 를 만든다. 이 그래프 덕분에 번들러는 “프로젝트 전체를 하나의 구조로 이해”할 수 있다.

  2. Transpile (트랜스파일)

    ESNext, TypeScript, JSX 등을 브라우저가 이해할 수 있는 JavaScript로 변환한다. Babel/SWC/esbuild 등을 내부적으로 사용하며, 이 단계에서 optional chaining, async/await, private fields 등이 모두 과거 브라우저에서도 동작하도록 변환된다.

  3. Bundle (모듈 결합)

    CJS, ESM 등 다양한 모듈을 하나의 실행 컨텍스트 안에서 동작하도록 병합한다. CJS → 번들러 런타임으로 묶음, ESM → import/export를 분석해 코드 레벨에서 통합 이 과정이 곧 “번들링”이다.

  4. Optimize (최적화)

    여기서부터 번들러는 단순 결합을 넘어 브라우저 성능까지 관리한다.

    • Minify/난독화 + 소스맵
    • 해싱 + 캐싱 전략
    • Tree-shaking
    • Code-splitting

Bundler가 해주는 최적화

사실 번들러라기 보단, 빌드도구라고 말하는 것이 적합하다.
이 빌드 도구들은 번들링 된 결과물이 나온 이후에 최적화를 진행하게 되는데,
각각에 대해 간략하게 알아보자

1. Minify와 SourceMap — 파일을 작게 만들되, 디버깅은 유지하기

최소화/난수화와 소스 맵은 프로덕션 환경의 성능 최적화에 필수적이다.
Minify를 통해, 파일 크기를 최소화하여 웹페이지 로딩 속도를 향상시킬 수 있다.

  • 공백 및 주석 제거: 코드의 기능을 변경하지 않고, 불필요한 공백, 줄바꿈, 주석 등을 모두 제거
  • 단축된 변수명 사용myLongVariableName과 같이 긴 변수명이나 함수명을 ab와 같이 짧은 문자로 변경
  • 코드 최소화 과정에서 발생하는 변수명 변경은 기본적인 난수화 효과를 제공한다.

SourceMap

최소화/난수화된 코드는 개발자가 디버깅하기 매우 어렵다. 소스 맵은 이 문제를 해결하기 위해 도입된 기능입니다.

  1. 각 빌드단계를 거치면서 소스맵.map 파일을 생성한다,
  2. 개발자 도구에서 코드를 키면, 브라우저가 .map 파일을 요청하고 불러오게 된다.
  3. 이를 통해, 개발모드에서 코드를 볼 수 있다.

그래서 운영에서는 소스맵을 삭제해야된다. Sentry에 보내는것 까지만하고 삭제하지 않으면 코드가 모두 노출 될 수 있다.


2. Hashing과 Caching — 캐시를 효율적으로 관리하기

Hash란, 해시 함수를 통해 고유한 문자열(해시)을 말한다.
해싱은 캐시 무효화 = 캐시버스팅을 위해 사용되는데,

main.js → main.a1b2c3.js

파일 형상을 보고 고유한 문자열을 생성해둔다면, 파일 명이 달라, 캐싱이 깨지게 된다. 즉, 형상이 바뀌면 파일 명을 다르게하여, 오래된 JS 대신 새로운 컨텐츠를 바로 받아볼 수 있도록 만든다.

내용이 바뀌면 파일명도 바뀌므로 브라우저는 항상 최신 파일을 불러오고
반대로 내용이 동일하다면, 파일명도 동일 → 캐시된 파일을 바로 사용 → 빠른 로딩을 할 수 있다.


3. Code Splitting — 필요할 때만 필요한 JS만 받기

하나의 큰 번들을 무조건 한 번에 내려받는 것도 문제다. 브라우저가 JS를 파싱하는 시간이 오래 걸리기 때문이다. 그래서 요즘 번들러는 “코드를 여러 조각으로 나누는 기술” Code Splitting을 제공한다.

// Dynamic Import 기반 스플리팅
import('./AdminPage.js');

이 한 줄을 보면 번들러는: 이 모듈은 초기 로드에는 필요 없다고 판단하여, 별도의 청크로 분리하게 된다. 그리고 이 청크를 원하는 순간에 lazy loading하게 되면, 초기 JS 파일을 줄이면서, 필요한 순간에 로딩하도록 만들 수 있다.

Next.js App Router에선 기본적으로 page.tsx 단위로 청크 분리가 되며,
Suspense 단위로도 청크가 분리된다.

SplitChunks — 중복된 라이브러리 제거

여러 청크에 lodash가 포함되어 있다면, 브라우저는 lodash를 여러 번 다운로드하게 된다. splitChunks는 이런 중복을 감지해 공통 라이브러리를 vendor 청크로 분리한다. 그리고 여러 페이지에서 공유 가능하다.

// webpack.config.js
module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    clean: true,
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
  },
};

이렇게 해두면,

dist/
 ├─ bundle.js              ← entry 청크
 ├─ src_pageA_js.js        ← pageA 청크
 ├─ src_pageB_js.js        ← pageB 청크
 └─ vendors-node_modules_lodash_js.js   ← 공통 vendor 청크 (lodash)

공통 청크로 분리된다.


4. Tree-shaking — 사용되지 않는 코드 제거하기

Tree-shaking은 사용되지 않는 export를 번들에서 제거하는 기술”이다. Tree-shaking을 통해 번들되는 파일을 최소화할 수 있다.

참고로 ESM은 import/export가 정적이어서, 빌드 시점에 “사용되지 않는 코드”를 정확히 판단할 수 있다.
반대로, CJS에서는 require/module.exports가 동적이고, module.exports이라는 하나의 객체만 내보내기 때문에 런타임이 되어서야 사용되는지 아닌지 알 수 있다. 그래서 CJS 기반 라이브러리는 tree-shaking이 거의 안 된다.

Side Effects — 번들러가 함부로 지우면 안 되는 코드

모듈이 “import되는 순간 실행되는 코드”를 side effect라고 한다.

console.log('나는 실행될 때 무조건 로그를 남겨야한다');

이런 모듈이 있다면, 꼭 필요하지만, import되는 곳이 없기 때문에 tree-shaking으로 사라질 수도 있다. 만약 라이브러리를 만든다면 package.json의 sideEffects 프로퍼티에 사이드이펙트 여부를 명시해야, Tree-Shaking 당시에 생존 여부를 결정할 수 있다.

참고로, esbuild보다 rollup이 sideEffect를 잘 감지하는 것으로 알려져 있다.